Skip to content

feat(paper): an hourly-cadence paper profile — evidence at a collectable rate (#337) - #344

Merged
eaitbrahim merged 2 commits into
mainfrom
feat/337-paper-hourly-profile
Aug 17, 2026
Merged

feat(paper): an hourly-cadence paper profile — evidence at a collectable rate (#337)#344
eaitbrahim merged 2 commits into
mainfrom
feat/337-paper-hourly-profile

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What & why

The paper deployment evaluates once per UTC day and daily-turtle rules fire 1.19–3.20 times per asset-year — the promotion gate's n=100 is 31–84 years away. The same rules on ONE_HOUR bars fire ~50/asset-year (median n≈268 over the cached 5-year window), making the sample collectable in months. The honest caveat, stated up front because it changes nothing about the decision: the hourly configuration is measured NET-NEGATIVE — 0 of 90 / 0 of 82 cells at every reachable fee (docs/experiments/2026-08-13-restated-under-a-production-faithful-engine.md). This profile exists to produce ADMISSIBLE EVIDENCE — rail vetoes, outcomes, pending lifespans, intent divergence: the things a backtest cannot observe — not profitability. That caveat is pinned in the config header, the plist comment, and the runbook by tests, so it cannot silently disappear.


Investigation

1. How does the agent map rules to candle granularity?

A cycle feeds each rule candles of the rule's DECLARED granularity, not the profile's configured granularity and not "whatever was fetched". The plumbing:

  • agent.run_once polls and reads every granularity the profile configures (keel/agent.py:855granularities = list(config.market_data.granularities); :1064candles_by_tf = {g: repo.get_candles(product_id, g) for g in granularities}). The profile's config is the menu, not the choice.
  • The choice is the rule's own attribute: engine._trading_granularity (keel/strategy/engine.py:225-237) returns rule.granularity / rule.timeframe if declared, else the finest available. agent._entry_gate_granularity (keel/agent.py:221-249) reads the same attributes to gate entries (falling back to the coarsest configured series for rules that declare neither — the DCA hazard). backtest._rule_trading_tf (keel/strategy/backtest.py:208-218) does the same for backtests.
  • Turtle's granularity was a hard-coded attribute, not a param: self.granularity = Granularity.ONE_DAY fixed in __init__ (keel/strategy/rules/turtle_breakout.py, pre-PR line 140), and detect()/exit_signal() read the ONE_DAY key unconditionally via _completed_days. No configuration could point the rule at another series — which is why the 2026-08-11 hourly corpus had to hand hourly bars to a rule that "believed they were days" (docs/experiments/2026-08-11-hourly-backtest-turtle-breakout.md §2/§7: backtest keys the fetched ONE_HOUR series under the rule's declared ONE_DAY, "the rule cannot tell").
  • Trading granularity is configured per rule row (rules.params), constrained by the profile's market_data.granularities: PullbackContinuation declares granularity (constructor param, default ONE_HOUR) and RsiMeanReversion declares timeframe (dataclass field, persisted as self.timeframe.value, coerced back by agent._GRANULARITY_PARAMS). Pullback is the cautionary tale: it accepts granularity but does NOT persist it in describe()["params"], so rules add refuses it rather than silently rebuild the rule at the default on a different candle series (keel/commands/rules.py:822-824, 967-984).

Consequence: an hourly paper profile needs turtle's declaration itself to be a param. This PR adds it (see below).

2. Can an hourly cycle keep ONE_HOUR bars current within venue limits?

Yes, comfortably:

  • poll_once (keel/data/market_feed.py:184-221) fetches, per (product, granularity) pair, only candles strictly newer than the latest stored and no later than the most recently closed one. Steady state at hourly cadence: ~one small request per pair per cycle. This profile: 5 traded products × 3 configured series = ~15 read-only candle requests per hour (ONE_DAY accrues 1 bar/day, ONE_HOUR 1/hour, FIFTEEN_MINUTE 4/hour) — far under Coinbase's public-endpoint budget, and identical in shape to what the live profile's 24 hourly triggers already do today.
  • The 350-candle cap self-heals rather than wedging. Coinbase rejects ranges over ~350 candles; MAX_CANDLES_PER_REQUEST = 300 (keel/data/history.py:30) and market_feed._request_windows/_poll_catch_up (keel/data/market_feed.py:61-77, 150-181) tile any catch-up range into ≤300-candle windows, upserting per window. repair.py applies the same chunking to interior gaps and records "absent at source" windows so permanently empty ones stop retrying. So a machine that was off for days catches up in ONE poll — 300 hourly bars ≈ 12.5 days per window, and windows repeat.
  • Freshness gates align with the cadence: rail 12's staleness window is interval_sec × FEED_STALENESS_CYCLES (keel/execution/guards.py:125) = 3600×3 = 3h here, on the finest configured series (FIFTEEN_MINUTE). The entry gate freshness.entry_bar_ready (keel/data/freshness.py:126-237) for an ONE_HOUR-declared rule requires the newest stored hourly bar to be the expected one and every finer series to have crossed the boundary — the :20 trigger's 20-minute publication margin (same margin com.keel.live uses for the same reason) keeps that from racing; when it does race, the cycle exits DATA_NOT_READY_EXIT=4, the hour goes unstamped, and the next trigger retries.

3. What defines a "profile"?

Four tracked artifacts per profile (tracked in-repo since 2026-08-03 per docs/RELEASING.md:101-104; the scripts' older "gitignored" header lines are stale on that point and this PR's new files say "tracked"): config (config.paperforward.yaml / config.live-sandbox.yaml), database (keel.db / keel-live.db — the --db CLI flag, default keel.db), launchd plist (com.keel.paperforward.plist — local-anchored, 09:00–20:00 hourly triggers + RunAtLoad, LOCAL day-stamp; com.keel.live.plist — 24 hourly triggers at :20, UTC day-stamp, where the stamp is a correctness mechanism because nothing on the live path dedupes an entry, pinned by tests/test_schedule.py), and run script + wrapper (paperforward-run.sh + keel-paper; keel-live-run.sh + keel-live, the wrapper pinning --config X --db Y together so --db's keel.db default can never silently cross the ledgers). The operator-facing contract for the pair lives in docs/operator-runbook.md "Paper vs. live". This PR adds the third profile in exactly that shape: config.paper-hourly.yaml + keel-paperhourly.db + com.keel.paper-hourly.plist + paper-hourly-run.sh + keel-paperhourly.


The code change (and the default-compatibility story)

TurtleBreakout gains granularity: Granularity = Granularity.ONE_DAY:

  • Declared exactly the way RSI declares timeframe (the convention that round-trips): persisted in params as granularity.value (a JSON-plain string), registered in agent._GRANULARITY_PARAMS so build_rule_from_params coerces "ONE_HOUR" back to the enum. Not Pullback's non-persisted convention, which rules add refuses.
  • detect()/exit_signal() read self._trading_series(candles_by_tf): the ONE_DAY default keeps _completed_days' forming-bar guard verbatim (the account-sim lookahead guard, pinned by existing tests); any other declared granularity reads that series verbatim — the same contract pullback/rsi already trade under (agent persists only closed candles; the sim decides at the current bar's close). An absent key declines as insufficient history rather than silently falling back to another granularity's bars.
  • Default compatibility: turtle rows already exist in keel.db and keel-live.db with no granularity key; they rebuild at the ONE_DAY default and keep meaning exactly what they meant. This is the asymmetry _params_delta (keel/commands/rules.py:734-740) already documents for any kind that grows a param, and deploy/live-rules.json is deliberately NOT regenerated (it mirrors the live rows, which are unchanged). Pinned by test_a_row_written_before_the_param_existed_defaults_to_daily.
  • Everything downstream follows with no further code: the entry gate (_entry_gate_granularity), the engine's trading-TF and higher-TF bias gate (an hourly turtle gets the ONE_DAY bias check, like every hourly rule), and backtest._rule_trading_tf all read the declared attribute.

The deployment profile

  • config.paper-hourly.yaml — paperforward's exact universe (same allowlist/weights/caps/fees/paper seed, pinned by test) at interval_sec: 3600, header carrying the net-negative caveat and the separate-database contract.
  • com.keel.paper-hourly.plist — 24 hourly triggers at :20 + RunAtLoad, comment blocks matching the existing plists (including the XML no-double-hyphen rule).
  • paper-hourly-run.shits own stamp semantics: stamps the UTC hour (date -u '+%Y-%m-%dT%H'). The paperforward day-stamp is daily-grained and would collapse 23 of 24 cycles into no-ops (the exact regression a copy-paste would ship — mutation-checked red, see below). The stamp is cadence bookkeeping here, not the live path's duplicate-entry barrier: paper already refuses a second entry while a product is open (strategy/paper.py), but duplicate cycles would still inflate the rail-veto/no-signal evidence counts.
  • keel-paperhourly — wrapper pinning --config config.paper-hourly.yaml --db keel-paperhourly.db.
  • docs/operator-runbook.md — third column in the paper-vs-live table + "The hourly evidence profile" section: purpose (evidence cadence), the net-negative caveat, the separate database with exact bootstrap (keel migrate --db, per-product rules add --params '{"granularity": "ONE_HOUR"}', deliberate rules promote --force with the reason, keel fetch), the one-param difference from every other turtle row, and the honest limits (an hour lost to power-off is lost; the runner cannot replay bars).

Tests-first evidence

  • Tests written first, seen failing for the right reason

Red (before the param existed), failing on the assertion meant to assert — not an import error:

FAILED tests/strategy/test_turtle_breakout.py::TestDeclaredGranularity::test_the_default_is_daily_and_is_persisted
    assert rule.params["granularity"] == "ONE_DAY"
E       KeyError: 'granularity'

plus test_an_hourly_rule_detects_on_the_one_hour_series (TypeError: unexpected kwarg) and tests/test_agent.py::test_coerced_param_keys_... (frozenset mismatch). Green after the change: same tests pass, full suite below.

For the runner's regression tests (written after the runner, so discrimination was proven by mutation instead — both mutations confirmed red, then reverted and re-verified green):

  • mutating the stamp to daily-grained (date -u '+%Y-%m-%d') → test_the_next_utc_hour_runs_its_own_cycle FAILED (1 failed);
  • dropping the --db keel-paperhourly.db pin → test_the_cycle_runs_the_hourly_config_against_its_own_database FAILED (1 failed).

Gates (all must pass)

  • uv run ruff check clean — All checks passed!
  • uv run mypy clean — Success: no issues found in 238 source files
  • uv run pytest -q green — 2909 passed, 1 skipped in 36.85s

Scope check

Closes #337

…ble rate (#337)

The paper deployment evaluates once per UTC day and daily-turtle rules fire
1.19–3.20 times per asset-year, so the gate's n=100 is 31–84 years away.
The same rules on ONE_HOUR bars fire ~50/asset-year (median n≈268 over the
cached 5-year window) — measured NET-NEGATIVE (0 of 90 / 0 of 82 cells at
every reachable fee, restated 2026-08-13). This profile exists to produce
ADMISSIBLE EVIDENCE (rail vetoes, outcomes, pending lifespans, intent
divergence — unobservable in backtests), not profitability.

- TurtleBreakout: granularity is now a persisted constructor param
  (RsiMeanReversion.timeframe's convention), default ONE_DAY so every
  pre-existing row keeps meaning daily; detect()/exit_signal() read the
  declared granularity's series via _trading_series.
- agent: _GRANULARITY_PARAMS registers turtle_breakout's granularity, so
  hourly rows round-trip through the shared coercion boundary.
- Tracked deployment profile: config.paper-hourly.yaml (paperforward's
  universe at interval_sec 3600), com.keel.paper-hourly.plist (24 hourly
  triggers at :20 + RunAtLoad), paper-hourly-run.sh (UTC-HOUR stamp — the
  paperforward day-stamp would collapse 23 of 24 cycles), keel-paperhourly
  wrapper pinning config + keel-paperhourly.db.
- Operator runbook: third profile column + 'The hourly evidence profile'
  section (purpose, net-negative caveat, exact bootstrap).

Closes #337
…series staleness wording, runbook table rewrapped
@eaitbrahim
eaitbrahim merged commit 82afc5a into main Aug 17, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the feat/337-paper-hourly-profile branch August 17, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An hourly-cadence paper profile — evidence at a collectable rate

1 participant